Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 1f0d40bad1707c0e6cf3463b78ea5f19da3ffe44


Parents : fd3cce3
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-14T00:15:58Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-14T00:15:58Z

feat(demo): make Demo Mode reachable and populated in release builds (#6691)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes

15 files changed, 1209 insertions(+), 187 deletions(-)


Diff

diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
index 6c9ef51749..7ee222e957 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
@@ -20,11 +20,14 @@ import android.content.Context
import android.hardware.usb.UsbManager
import android.provider.Settings
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
@@ -44,6 +47,7 @@ class AndroidRadioTransportFactory(
private val buildConfigProvider: BuildConfigProvider,
private val usbRepository: UsbRepository,
private val usbManager: UsbManager,
+ hiddenFeaturesUnlock: HiddenFeaturesUnlock,
scanner: BleScanner,
bluetoothRepository: BluetoothRepository,
connectionFactory: BleConnectionFactory,
@@ -52,8 +56,26 @@ class AndroidRadioTransportFactory(
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
- override fun isMockTransport(): Boolean =
- buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
+ /**
+ * Demo Mode gate.
+ *
+ * Debug builds and Firebase Test Lab get it unconditionally, as before. Release builds get it only after the user
+ * performs the hidden-features gesture (five taps on the Settings app-version row) β€” the same deliberate,
+ * process-scoped unlock that reveals the firmware-excluded module screens. That keeps a permanently visible fake
+ * radio out of the picker for ordinary users while making Demo Mode genuinely reachable in a shipped build, which
+ * is what a Play reviewer with no LoRa hardware needs.
+ *
+ * [HiddenFeaturesUnlock.unlocked] is a hot [StateFlow], so no scope is needed to observe it here.
+ */
+ override val mockTransportEnabled: StateFlow<Boolean> =
+ if (buildConfigProvider.isDebug || isFirebaseTestLab()) {
+ MutableStateFlow(true)
+ } else {
+ hiddenFeaturesUnlock.unlocked
+ }
+
+ private fun isFirebaseTestLab(): Boolean =
+ Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
/**
* Probed once: the asset is baked into the APK, so its presence cannot change while the process lives. Empty counts

diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
index 4e681345f6..5a99d69932 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
@@ -45,11 +45,12 @@ abstract class BaseRadioTransportFactory(
'!',
-> true
- // Virtual transports are development aids. A release build must never bind one: `connections?address=m` is
- // reachable from any web page through the verified meshtastic.org app link.
+ // Virtual transports stay inadmissible until deliberately enabled: `connections?address=m` is reachable
+ // from any web page through the verified meshtastic.org app link, so a drive-by deep link must not be able
+ // to swap a user's real radio for a fake one.
InterfaceId.MOCK.id,
InterfaceId.REPLAY.id,
- -> isMockTransport()
+ -> mockTransportEnabled.value
else -> isPlatformAddressValid(address)
}

diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
index b728816039..7440aee9ca 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
@@ -17,7 +17,10 @@
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.update
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
@@ -25,7 +28,7 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.NodeAddress
-import org.meshtastic.core.model.util.getInitials
+import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportCallback
import org.meshtastic.proto.AdminMessage
@@ -33,6 +36,7 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
+import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
@@ -47,16 +51,38 @@ import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.User
-import kotlin.random.Random
import org.meshtastic.proto.Channel as ProtoChannel
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
-private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW)
+private val defaultLoRaConfig =
+ Config.LoRaConfig(
+ use_preset = true,
+ modem_preset = Config.LoRaConfig.ModemPreset.LONG_FAST,
+ region = Config.LoRaConfig.RegionCode.US,
+ hop_limit = 3,
+ tx_enabled = true,
+ )
private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY)
-/** A simulated transport that is used for testing in the simulator. */
+/**
+ * A simulated radio, selected as "Demo Mode" in the Connections screen.
+ *
+ * It answers the app's real two-stage `want_config` handshake and then feeds a small synthetic mesh β€” a populated node
+ * list with positions, signal readings and battery levels, one channel conversation, one direct-message thread, and
+ * telemetry that keeps accruing while the app is open. That makes the app fully explorable without any LoRa hardware,
+ * which is what an app-store reviewer and a first-time user both need.
+ *
+ * Three properties of the real protocol have to be honoured or the app never leaves "Loading node list":
+ * 1. The handshake is two-stage ([HandshakeConstants.CONFIG_NONCE] then [HandshakeConstants.NODE_INFO_NONCE]). Each
+ * stage must answer with only its own frames and its own `config_complete_id`. Re-sending `my_info` on stage 2
+ * resets the app's handshake state machine and the stage-2 completion is then rejected.
+ * 2. `node_info` frames are only accepted inside the handshake window, so every simulated node has to ship in stage 2.
+ * 3. Radio metrics (SNR/RSSI/hops) are only taken from packets that look like direct LoRa receptions β€”
+ * `transport_mechanism = TRANSPORT_LORA` and `hop_start == hop_limit`. Frames left at the proto defaults are treated
+ * as internal traffic and contribute no signal information at all.
+ */
@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
class MockRadioTransport(
private val callback: RadioTransportCallback,
@@ -64,19 +90,35 @@ class MockRadioTransport(
val address: String,
) : RadioTransport {
- companion object {
- private const val MY_NODE = 0x42424242
-
- @Suppress("MagicNumber")
- private val FAKE_SESSION_PASSKEY: okio.ByteString =
- okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77)
+ /**
+ * Hands out packet ids.
+ *
+ * Atomic because ids are drawn concurrently from the seed pass, the live-traffic ticker, the delayed reply jobs and
+ * the ack path. Two frames sharing an id is not cosmetic here: the app keys its message and node lists by packet
+ * id, and a duplicate key has crashed those lists before β€” exactly the screens a store reviewer is looking at while
+ * the demo runs.
+ */
+ private val packetIdCounter = atomic(FIRST_PACKET_ID)
+
+ /** Guards against re-seeding traffic if the app repeats stage 2 (e.g. after a handshake retry). */
+ private val trafficStarted = atomic(false)
+
+ /**
+ * Every coroutine this transport owns: the live-traffic ticker, the delayed replies and the delayed acks.
+ *
+ * An atomic reference to an immutable list rather than a `mutableListOf`, because [close] drains it from the
+ * caller's context while `handleSendToRadio` is still appending to it β€” concurrent iteration and mutation of a
+ * plain list throws ConcurrentModificationException.
+ */
+ private val pendingJobs = atomic<List<Job>>(emptyList())
+
+ private fun nextPacketId(): Int = packetIdCounter.getAndIncrement()
+
+ /** Registers a coroutine for cancellation by [close], dropping the ones that have already finished. */
+ private fun track(job: Job) {
+ pendingJobs.update { jobs -> jobs.filterNot { it.isCompleted } + job }
}
- private var currentPacketId = 50
-
- // an infinite sequence of ints
- private val packetIdSequence = generateSequence { currentPacketId++ }.iterator()
-
override fun start() {
Logger.i { "Starting the mock transport" }
callback.onConnect() // Tell clients they can use the API
@@ -85,14 +127,22 @@ class MockRadioTransport(
override fun handleSendToRadio(p: ByteArray) {
val pr = ToRadio.ADAPTER.decode(p)
- // Intercept want_config handshake β€” send config response only when requested,
- // mirroring the behaviour of real firmware which waits for want_config_id.
- val wantConfigId = pr.want_config_id ?: 0
- if (wantConfigId != 0) {
- sendConfigResponse(wantConfigId)
- return
+ // Intercept the want_config handshake. Real firmware answers each stage separately and only when asked, and the
+ // app's state machine depends on that: see the class doc.
+ when (pr.want_config_id) {
+ null,
+ 0,
+ -> handleOutboundTraffic(pr)
+
+ HandshakeConstants.CONFIG_NONCE -> sendConfigStage()
+
+ HandshakeConstants.NODE_INFO_NONCE -> sendNodeInfoStage()
+
+ else -> Logger.w { "Mock transport ignoring unknown want_config_id ${pr.want_config_id}" }
}
+ }
+ private fun handleOutboundTraffic(pr: ToRadio) {
val packet = pr.packet
if (packet != null) {
sendQueueStatus(packet.id)
@@ -104,6 +154,11 @@ class MockRadioTransport(
data != null && data.portnum == PortNum.ADMIN_APP ->
handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload))
+ data != null && data.portnum == PortNum.TEXT_MESSAGE_APP -> {
+ if (packet?.want_ack == true) sendFakeAck(pr)
+ sendSimulatedReply(packet)
+ }
+
packet != null && packet.want_ack == true -> sendFakeAck(pr)
else -> Logger.i { "Ignoring data sent to mock transport $pr" }
@@ -139,10 +194,7 @@ class MockRadioTransport(
sendAdmin(packet.to, packet.from, packet.id) {
copy(
get_module_config_response =
- ModuleConfig(
- statusmessage =
- ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."),
- ),
+ ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = MY_NODE_STATUS)),
)
}
@@ -152,81 +204,310 @@ class MockRadioTransport(
override suspend fun close() {
Logger.i { "Closing the mock transport" }
+ // Drain and cancel in one atomic swap so a job added concurrently is either cancelled here or belongs to the
+ // list the next close() drains β€” never silently dropped while still running.
+ pendingJobs.getAndSet(emptyList()).forEach { it.cancel() }
+ }
+
+ // ── Handshake ─────────────────────────────────────────────────────────────────────────────
+
+ /** Stage 1: our own identity, device metadata, config and channels. Deliberately carries no `node_info`. */
+ private fun sendConfigStage() {
+ Logger.d { "Mock transport answering config stage" }
+ val metadata = DeviceMetadata(firmware_version = FIRMWARE_VERSION, hw_model = HardwareModel.ANDROID_SIM)
+ val frames =
+ listOf(
+ FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE, reboot_count = 3)),
+ FromRadio(metadata = metadata),
+ FromRadio(config = Config(lora = defaultLoRaConfig)),
+ FromRadio(config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))),
+ FromRadio(
+ config =
+ Config(
+ position =
+ Config.PositionConfig(
+ position_broadcast_secs = 900,
+ position_broadcast_smart_enabled = true,
+ gps_enabled = true,
+ ),
+ ),
+ ),
+ FromRadio(channel = defaultChannel),
+ FromRadio(config_complete_id = HandshakeConstants.CONFIG_NONCE),
+ )
+ frames.forEach { callback.handleFromRadio(it.encode()) }
+ }
+
+ /** Stage 2: the node database. This is the only window in which the app accepts `node_info`. */
+ private fun sendNodeInfoStage() {
+ Logger.d { "Mock transport answering node-info stage with ${SIM_PEERS.size + 1} nodes" }
+ callback.handleFromRadio(FromRadio(node_info = localNodeInfo()).encode())
+ SIM_PEERS.forEach { peer -> callback.handleFromRadio(FromRadio(node_info = peer.toNodeInfo()).encode()) }
+ callback.handleFromRadio(FromRadio(config_complete_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+
+ if (trafficStarted.compareAndSet(expect = false, update = true)) {
+ track(scope.handledLaunch { seedTraffic() })
+ }
+ }
+
+ private fun localNodeInfo() = NodeInfo(
+ num = MY_NODE,
+ last_heard = nowSeconds.toInt(),
+ user =
+ User(
+ id = NodeAddress.numToDefaultId(MY_NODE),
+ long_name = "Demo Handset",
+ short_name = "DEMO",
+ hw_model = HardwareModel.ANDROID_SIM,
+ role = Config.DeviceConfig.Role.CLIENT,
+ ),
+ position = MY_POSITION.toProto(),
+ device_metrics =
+ DeviceMetrics(
+ battery_level = 78,
+ voltage = 3.98f,
+ channel_utilization = 8.4f,
+ air_util_tx = 1.9f,
+ uptime_seconds = 7_240,
+ ),
+ hops_away = 0,
+ )
+
+ private fun SimPeer.toNodeInfo() = NodeInfo(
+ num = num,
+ // Without last_heard every simulated node reads as offline and disappears the moment the user turns on the
+ // node list's "online only" filter.
+ last_heard = nowSeconds.toInt() - secondsSinceHeard,
+ user =
+ User(
+ id = NodeAddress.numToDefaultId(num),
+ long_name = longName,
+ short_name = shortName,
+ hw_model = hwModel,
+ role = role,
+ ),
+ position = SimPosition(latitude, longitude, altitude).toProto(),
+ device_metrics =
+ DeviceMetrics(battery_level = batteryLevel, voltage = voltage, uptime_seconds = uptimeSeconds),
+ snr = snr,
+ hops_away = hops,
+ channel = 0,
+ )
+
+ // ── Simulated traffic ─────────────────────────────────────────────────────────────────────
+
+ /**
+ * Seeds a mesh that already has history, then keeps it gently alive.
+ *
+ * The seed pass is spaced out because message and telemetry rows are timestamped when the app persists them, not
+ * from the payload: a burst emitted inside the same millisecond collapses into one indistinguishable clump of chart
+ * points and messages.
+ */
+ private suspend fun seedTraffic() {
+ SIM_PEERS.forEach { peer ->
+ callback.handleFromRadio(peer.positionPacket(nextPacketId()).encode())
+ delay(SEED_SPACING_MS)
+ }
+
+ SIM_PEERS.take(TELEMETRY_PEER_COUNT).forEach { peer ->
+ callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick = 0).encode())
+ delay(SEED_SPACING_MS)
+ }
+
+ WEATHER_PEER_INDEXES.forEach { index ->
+ val peer = SIM_PEERS[index]
+ callback.handleFromRadio(peer.environmentTelemetryPacket(nextPacketId(), tick = 0).encode())
+ delay(SEED_SPACING_MS)
+ }
+
+ callback.handleFromRadio(SIM_PEERS[0].neighborInfoPacket(nextPacketId()).encode())
+ delay(SEED_SPACING_MS)
+ callback.handleFromRadio(SIM_PEERS[1].nodeStatusPacket(nextPacketId()).encode())
+ delay(SEED_SPACING_MS)
+
+ // Each message is stamped progressively closer to now, so the thread reads as a conversation that unfolded over
+ // the last while rather than a block of messages that all arrived in the same second.
+ CHANNEL_CONVERSATION.forEachIndexed { index, (peerIndex, text) ->
+ val peer = SIM_PEERS[peerIndex]
+ callback.handleFromRadio(
+ peer
+ .textPacket(
+ id = nextPacketId(),
+ to = BROADCAST_ADDR,
+ text = text,
+ ageSeconds = messageAgeSeconds(CHANNEL_CONVERSATION.size, index),
+ )
+ .encode(),
+ )
+ delay(SEED_SPACING_MS)
+ }
+
+ DIRECT_CONVERSATION.forEachIndexed { index, text ->
+ callback.handleFromRadio(
+ SIM_PEERS[DIRECT_PEER_INDEX].textPacket(
+ id = nextPacketId(),
+ to = MY_NODE,
+ text = text,
+ ageSeconds = messageAgeSeconds(DIRECT_CONVERSATION.size, index),
+ )
+ .encode(),
+ )
+ delay(SEED_SPACING_MS)
+ }
+
+ streamLiveTelemetry()
}
- // / Generate a fake text message from a node
- private fun makeTextMessage(numIn: Int) = FromRadio(
+ /** How long ago the message at [index] of a [count]-message seeded thread was "received". Oldest first. */
+ private fun messageAgeSeconds(count: Int, index: Int) = (count - index) * MESSAGE_SPACING_SECONDS
+
+ /**
+ * Keeps the demo mesh breathing: one peer reports in per tick, so the telemetry charts gain points with distinct
+ * timestamps and the node list's "last heard" values stay fresh while the reviewer explores.
+ */
+ private suspend fun streamLiveTelemetry() {
+ var tick = 1
+ while (true) {
+ delay(LIVE_TICK_MS)
+ val peer = SIM_PEERS[tick % TELEMETRY_PEER_COUNT]
+ callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick).encode())
+ if (tick % WEATHER_TICK_INTERVAL == 0) {
+ val weatherPeer = SIM_PEERS[WEATHER_PEER_INDEXES.first()]
+ callback.handleFromRadio(weatherPeer.environmentTelemetryPacket(nextPacketId(), tick).encode())
+ }
+ tick++
+ }
+ }
+
+ /**
+ * Answers a text the user just sent, so the demo has a two-way conversation rather than a wall of inbound messages.
+ * A broadcast gets a reply on the same channel; a direct message gets a direct reply from its addressee.
+ */
+ private fun sendSimulatedReply(packet: MeshPacket?) {
+ if (packet == null) return
+ val isBroadcast = packet.to == BROADCAST_ADDR
+ val responder =
+ if (isBroadcast) {
+ SIM_PEERS[DIRECT_PEER_INDEX]
+ } else {
+ SIM_PEERS.firstOrNull { it.num == packet.to } ?: return
+ }
+ val replyTo = if (isBroadcast) BROADCAST_ADDR else MY_NODE
+
+ track(
+ scope.handledLaunch {
+ delay(REPLY_DELAY_MS)
+ callback.handleFromRadio(
+ responder
+ .textPacket(id = nextPacketId(), to = replyTo, text = AUTO_REPLY_TEXT, ageSeconds = 0)
+ .encode(),
+ )
+ },
+ )
+ }
+
+ // ── Packet builders ──────────────────────────────────────────────────────────────────────
+
+ /**
+ * Base envelope for a packet "received over the air" from [SimPeer].
+ *
+ * `transport_mechanism` and the matching `hop_start`/`hop_limit` are load-bearing, not decoration: the app only
+ * harvests SNR, RSSI and hop count from packets that pass its direct-LoRa test.
+ */
+ private fun SimPeer.packet(id: Int, to: Int, ageSeconds: Int, data: Data) = MeshPacket(
+ id = id,
+ from = num,
+ to = to,
+ channel = 0,
+ rx_time = (nowSeconds - ageSeconds).toInt(),
+ rx_snr = snr,
+ rx_rssi = rssi,
+ hop_start = DEFAULT_HOP_START,
+ hop_limit = DEFAULT_HOP_START - hops,
+ transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
+ decoded = data,
+ )
+
+ private fun SimPeer.textPacket(id: Int, to: Int, text: String, ageSeconds: Int) = FromRadio(
packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
- Data(
- portnum = PortNum.TEXT_MESSAGE_APP,
- payload = "This simulated node sends Hi!".encodeUtf8(),
- ),
+ packet(
+ id = id,
+ to = to,
+ ageSeconds = ageSeconds,
+ data = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = text.encodeUtf8()),
),
)
- private fun makeNeighborInfo(numIn: Int) = FromRadio(
+ private fun SimPeer.positionPacket(id: Int) = FromRadio(
packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
+ packet(
+ id = id,
+ to = BROADCAST_ADDR,
+ ageSeconds = 0,
+ data =
Data(
- portnum = PortNum.NEIGHBORINFO_APP,
- payload =
- NeighborInfo(
- node_id = numIn,
- last_sent_by_id = numIn,
- node_broadcast_interval_secs = 60,
- neighbors =
- listOf(
- Neighbor(
- node_id = numIn + 1,
- snr = 10.0f,
- last_rx_time = nowSeconds.toInt(),
- node_broadcast_interval_secs = 60,
- ),
- Neighbor(
- node_id = numIn + 2,
- snr = 12.0f,
- last_rx_time = nowSeconds.toInt(),
- node_broadcast_interval_secs = 60,
- ),
- ),
- )
- .encode()
- .toByteString(),
+ portnum = PortNum.POSITION_APP,
+ payload = SimPosition(latitude, longitude, altitude).toProto().encode().toByteString(),
),
),
)
- private fun makePosition(numIn: Int) = FromRadio(
+ /**
+ * Device telemetry for [tick], drifted so the charts show a trend rather than a flat line.
+ *
+ * Both the percentage and the voltage are clamped, not just the percentage: a tick is 20s, so an unbounded slope
+ * takes the voltage through 0V inside a couple of hours and the battery and telemetry views then render a cell that
+ * cannot physically exist.
+ */
+ private fun SimPeer.deviceTelemetryPacket(id: Int, tick: Int): FromRadio {
+ val driftedBattery = (batteryLevel - tick).coerceIn(MIN_BATTERY_PERCENT, MAX_BATTERY_PERCENT)
+ val driftedVoltage = (voltage - tick * VOLTAGE_DRIFT_PER_TICK).coerceAtLeast(MIN_CELL_VOLTAGE)
+ return FromRadio(
+ packet =
+ packet(
+ id = id,
+ to = BROADCAST_ADDR,
+ ageSeconds = 0,
+ data =
+ Data(
+ portnum = PortNum.TELEMETRY_APP,
+ payload =
+ Telemetry(
+ device_metrics =
+ DeviceMetrics(
+ battery_level = driftedBattery,
+ voltage = driftedVoltage,
+ channel_utilization = 6f + (tick % 5) * 1.5f,
+ air_util_tx = 1.2f + (tick % 4) * 0.4f,
+ uptime_seconds = uptimeSeconds + tick * (LIVE_TICK_MS / 1000).toInt(),
+ ),
+ )
+ .encode()
+ .toByteString(),
+ ),
+ ),
+ )
+ }
+
+ private fun SimPeer.environmentTelemetryPacket(id: Int, tick: Int) = FromRadio(
packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
+ packet(
+ id = id,
+ to = BROADCAST_ADDR,
+ ageSeconds = 0,
+ data =
Data(
- portnum = PortNum.POSITION_APP,
+ portnum = PortNum.TELEMETRY_APP,
payload =
- ProtoPosition(
- latitude_i = org.meshtastic.core.model.Position.degI(32.776665),
- longitude_i = org.meshtastic.core.model.Position.degI(-96.796989),
- altitude = 150,
- time = nowSeconds.toInt(),
- precision_bits = 15,
+ Telemetry(
+ environment_metrics =
+ EnvironmentMetrics(
+ // Temperature AND humidity must both be present or the Environment tab
+ // stays empty.
+ temperature = 18.5f + (tick % 7) * 0.4f,
+ relative_humidity = 47f + (tick % 5) * 1.5f,
+ barometric_pressure = 1013.2f + (tick % 3) * 0.3f,
+ ),
)
.encode()
.toByteString(),
@@ -234,28 +515,29 @@ class MockRadioTransport(
),
)
- private fun makeTelemetry(numIn: Int) = FromRadio(
+ private fun SimPeer.neighborInfoPacket(id: Int) = FromRadio(
packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
+ packet(
+ id = id,
+ to = BROADCAST_ADDR,
+ ageSeconds = 0,
+ data =
Data(
- portnum = PortNum.TELEMETRY_APP,
+ portnum = PortNum.NEIGHBORINFO_APP,
payload =
- Telemetry(
- time = nowSeconds.toInt(),
- device_metrics =
- DeviceMetrics(
- battery_level = 85,
- voltage = 4.1f,
- channel_utilization = 0.12f,
- air_util_tx = 0.05f,
- uptime_seconds = 123456,
- ),
+ NeighborInfo(
+ node_id = num,
+ last_sent_by_id = num,
+ node_broadcast_interval_secs = 900,
+ neighbors =
+ SIM_PEERS.drop(1).take(3).map { neighbor ->
+ Neighbor(
+ node_id = neighbor.num,
+ snr = neighbor.snr,
+ last_rx_time = nowSeconds.toInt(),
+ node_broadcast_interval_secs = 900,
+ )
+ },
)
.encode()
.toByteString(),
@@ -263,19 +545,16 @@ class MockRadioTransport(
),
)
- private fun makeNodeStatus(numIn: Int) = FromRadio(
+ private fun SimPeer.nodeStatusPacket(id: Int) = FromRadio(
packet =
- MeshPacket(
- id = packetIdSequence.next(),
- from = numIn,
- to = 0xffffffff.toInt(), // broadcast
- rx_time = nowSeconds.toInt(),
- rx_snr = 1.5f,
- decoded =
+ packet(
+ id = id,
+ to = BROADCAST_ADDR,
+ ageSeconds = 0,
+ data =
Data(
portnum = PortNum.NODE_STATUS_APP,
- payload =
- StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(),
+ payload = StatusMessage(status = PEER_NODE_STATUS).encode().toByteString(),
),
),
)
@@ -283,7 +562,7 @@ class MockRadioTransport(
private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio(
packet =
MeshPacket(
- id = packetIdSequence.next(),
+ id = nextPacketId(),
from = fromIn,
to = toIn,
rx_time = nowSeconds.toInt(),
@@ -316,64 +595,247 @@ class MockRadioTransport(
}
// / Send a fake ack packet back if the sender asked for want_ack
- private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch {
- val packet = pr.packet ?: return@handledLaunch
- delay(2000)
- callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode())
+ private fun sendFakeAck(pr: ToRadio) {
+ val packet = pr.packet ?: return
+ track(
+ scope.handledLaunch {
+ delay(ACK_DELAY_MS)
+ callback.handleFromRadio(makeAck(SIM_PEERS[DIRECT_PEER_INDEX].num, packet.from, packet.id).encode())
+ },
+ )
}
- private fun sendConfigResponse(configId: Int) {
- Logger.d { "Sending mock config response" }
-
- // / Generate a fake node info entry
- @Suppress("MagicNumber")
- fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio(
- node_info =
- NodeInfo(
- num = numIn,
- user =
- User(
- id = NodeAddress.numToDefaultId(numIn),
- long_name = "Sim ${numIn.toString(16)}",
- short_name = getInitials("Sim ${numIn.toString(16)}"),
- hw_model = HardwareModel.ANDROID_SIM,
- ),
- position =
- ProtoPosition(
- latitude_i = org.meshtastic.core.model.Position.degI(lat),
- longitude_i = org.meshtastic.core.model.Position.degI(lon),
- altitude = 35,
- time = nowSeconds.toInt(),
- precision_bits = Random.nextInt(10, 19),
- ),
- ),
+ /** One simulated peer in the demo mesh. */
+ private data class SimPeer(
+ val num: Int,
+ val longName: String,
+ val shortName: String,
+ val hwModel: HardwareModel,
+ val role: Config.DeviceConfig.Role,
+ val latitude: Double,
+ val longitude: Double,
+ val altitude: Int,
+ val batteryLevel: Int,
+ val voltage: Float,
+ val snr: Float,
+ val rssi: Int,
+ val hops: Int,
+ val secondsSinceHeard: Int,
+ val uptimeSeconds: Int,
+ )
+
+ /** Latitude/longitude/altitude triple, converted to the proto's scaled-integer representation on demand. */
+ private data class SimPosition(val latitude: Double, val longitude: Double, val altitude: Int) {
+ fun toProto() = ProtoPosition(
+ latitude_i = org.meshtastic.core.model.Position.degI(latitude),
+ longitude_i = org.meshtastic.core.model.Position.degI(longitude),
+ altitude = altitude,
+ time = nowSeconds.toInt(),
+ // 32 bits is "full precision"; the coarse end of the scale draws a large uncertainty circle instead of
+ // placing the node where it actually is.
+ precision_bits = 32,
+ sats_in_view = 9,
+ location_source = ProtoPosition.LocSource.LOC_INTERNAL,
)
+ }
- // Simulated network data to feed to our app
- val packets =
- arrayOf(
- // MyNodeInfo
- FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)),
- FromRadio(
- metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM),
+ private companion object {
+ const val MY_NODE = 0x42424242
+ const val BROADCAST_ADDR = -1 // 0xffffffff
+ const val FIRMWARE_VERSION = "9.9.9.abcdefg"
+ const val MY_NODE_STATUS = "Running Demo Mode β€” no radio attached."
+ const val PEER_NODE_STATUS = "Solar powered, up on the ridge."
+ const val AUTO_REPLY_TEXT = "Got it, thanks! Message received on the demo mesh."
+
+ /** First packet id handed out; low enough to stay clear of ids the app generates for its own sends. */
+ const val FIRST_PACKET_ID = 50
+
+ /**
+ * Floor of the simulated voltage drift, the counterpart to [MIN_BATTERY_PERCENT]: a nearly-flat Li-ion cell
+ * rests around here, so the charts settle on a plausible value instead of running off the bottom of the scale.
+ */
+ const val MIN_CELL_VOLTAGE = 3.2f
+ const val MIN_BATTERY_PERCENT = 5
+ const val MAX_BATTERY_PERCENT = 100
+ const val VOLTAGE_DRIFT_PER_TICK = 0.01f
+
+ /** Hop budget the simulated nodes transmit with; `hop_limit` is derived so the app can infer hop distance. */
+ const val DEFAULT_HOP_START = 3
+ const val DIRECT_PEER_INDEX = 0
+ const val TELEMETRY_PEER_COUNT = 4
+ val WEATHER_PEER_INDEXES = listOf(4)
+
+ /** Spacing between seeded frames; the app timestamps rows on persist, so a burst would collapse together. */
+ const val SEED_SPACING_MS = 120L
+ const val MESSAGE_SPACING_SECONDS = 240
+ const val LIVE_TICK_MS = 20_000L
+ const val WEATHER_TICK_INTERVAL = 3
+ const val REPLY_DELAY_MS = 2_500L
+ const val ACK_DELAY_MS = 2_000L
+
+ val FAKE_SESSION_PASSKEY: okio.ByteString = okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77)
+
+ val MY_POSITION = SimPosition(latitude = 32.776665, longitude = -96.796989, altitude = 138)
+
+ /**
+ * The demo mesh. Deterministic on purpose β€” the same mesh every launch makes the demo reproducible for
+ * screenshots, support requests and store reviews. Spread over ~15 km so the map has something to fit.
+ */
+ val SIM_PEERS =
+ listOf(
+ SimPeer(
+ num = MY_NODE + 1,
+ longName = "Riverside Base",
+ shortName = "RVSD",
+ hwModel = HardwareModel.HELTEC_V3,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = 32.802,
+ longitude = -96.769,
+ altitude = 152,
+ batteryLevel = 92,
+ voltage = 4.09f,
+ snr = 11.5f,
+ rssi = -62,
+ hops = 0,
+ secondsSinceHeard = 45,
+ uptimeSeconds = 128_400,
+ ),
+ SimPeer(
+ num = MY_NODE + 2,
+ longName = "Trail Runner",
+ shortName = "TRLR",
+ hwModel = HardwareModel.TRACKER_T1000_E,
+ role = Config.DeviceConfig.Role.TRACKER,
+ latitude = 32.7605,
+ longitude = -96.8305,
+ altitude = 145,
+ batteryLevel = 64,
+ voltage = 3.87f,
+ snr = 6.25f,
+ rssi = -84,
+ hops = 0,
+ secondsSinceHeard = 130,
+ uptimeSeconds = 41_900,
+ ),
+ SimPeer(
+ num = MY_NODE + 3,
+ longName = "Oak Cliff Repeater",
+ shortName = "OAKR",
+ hwModel = HardwareModel.RAK4631,
+ role = Config.DeviceConfig.Role.ROUTER,
+ latitude = 32.7395,
+ longitude = -96.8215,
+ altitude = 189,
+ batteryLevel = 100,
+ voltage = 4.14f,
+ snr = 9.0f,
+ rssi = -71,
+ hops = 0,
+ secondsSinceHeard = 20,
+ uptimeSeconds = 903_600,
+ ),
+ SimPeer(
+ num = MY_NODE + 4,
+ longName = "Deep Ellum Handheld",
+ shortName = "DEEP",
+ hwModel = HardwareModel.T_DECK,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = 32.7842,
+ longitude = -96.7845,
+ altitude = 141,
+ batteryLevel = 47,
+ voltage = 3.74f,
+ snr = -2.5f,
+ rssi = -103,
+ hops = 1,
+ secondsSinceHeard = 320,
+ uptimeSeconds = 9_800,
+ ),
+ SimPeer(
+ num = MY_NODE + 5,
+ longName = "Rooftop Weather",
+ shortName = "WTHR",
+ hwModel = HardwareModel.HELTEC_MESH_NODE_T114,
+ role = Config.DeviceConfig.Role.SENSOR,
+ latitude = 32.8145,
+ longitude = -96.8055,
+ altitude = 205,
+ batteryLevel = 88,
+ voltage = 4.02f,
+ snr = 4.75f,
+ rssi = -91,
+ hops = 1,
+ secondsSinceHeard = 210,
+ uptimeSeconds = 512_000,
),
+ SimPeer(
+ num = MY_NODE + 6,
+ longName = "Lakeside Solar",
+ shortName = "LAKE",
+ hwModel = HardwareModel.STATION_G2,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = 32.8365,
+ longitude = -96.7325,
+ altitude = 167,
+ batteryLevel = 73,
+ voltage = 3.94f,
+ snr = 1.5f,
+ rssi = -98,
+ hops = 2,
+ secondsSinceHeard = 640,
+ uptimeSeconds = 254_300,
+ ),
+ SimPeer(
+ num = MY_NODE + 7,
+ longName = "Bike Courier",
+ shortName = "BIKE",
+ hwModel = HardwareModel.TBEAM,
+ role = Config.DeviceConfig.Role.TRACKER,
+ latitude = 32.7688,
+ longitude = -96.7492,
+ altitude = 134,
+ batteryLevel = 31,
+ voltage = 3.62f,
+ snr = -6.5f,
+ rssi = -112,
+ hops = 2,
+ secondsSinceHeard = 1_180,
+ uptimeSeconds = 3_600,
+ ),
+ SimPeer(
+ num = MY_NODE + 8,
+ longName = "Field Kit Echo",
+ shortName = "ECHO",
+ hwModel = HardwareModel.T_ECHO,
+ role = Config.DeviceConfig.Role.CLIENT_MUTE,
+ latitude = 32.7215,
+ longitude = -96.7738,
+ altitude = 158,
+ batteryLevel = 56,
+ voltage = 3.81f,
+ snr = 3.25f,
+ rssi = -95,
+ hops = 1,
+ secondsSinceHeard = 2_400,
+ uptimeSeconds = 76_500,
+ ),
+ )
- // Fake NodeDB
- makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas
- makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
- FromRadio(config = Config(lora = defaultLoRaConfig)),
- FromRadio(config = Config(lora = defaultLoRaConfig)),
- FromRadio(channel = defaultChannel),
- FromRadio(config_complete_id = configId),
-
- // Done with config response, now pretend to receive some text messages
- makeTextMessage(MY_NODE + 1),
- makeNeighborInfo(MY_NODE + 1),
- makePosition(MY_NODE + 1),
- makeTelemetry(MY_NODE + 1),
- makeNodeStatus(MY_NODE + 1),
+ /** Seeded channel conversation, as (peer index, text) pairs. Oldest first. */
+ val CHANNEL_CONVERSATION =
+ listOf(
+ 0 to "Morning all β€” base station is back online after the power cut.",
+ 2 to "Copy that. Repeater on Oak Cliff is holding steady, 100% battery.",
+ 1 to "Out on the trail loop, signal is solid the whole way today.",
+ 4 to "Rooftop sensor reading 18.5C and 47% humidity if anyone cares.",
+ 0 to "Nice. Net check complete, everyone reporting in.",
)
- packets.forEach { p -> callback.handleFromRadio(p.encode()) }
+ /** Seeded direct-message thread from [DIRECT_PEER_INDEX]. Oldest first. */
+ val DIRECT_CONVERSATION =
+ listOf(
+ "Hey, are you still planning to bring the spare antenna tomorrow?",
+ "No rush β€” just let me know before you set off.",
+ )
}
}

diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
new file mode 100644
index 0000000000..d42912677a
--- /dev/null
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
@@ -0,0 +1,346 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.radio
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.repository.HandshakeConstants
+import org.meshtastic.core.repository.RadioTransportCallback
+import org.meshtastic.core.repository.TransportDisconnectReason
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Telemetry
+import org.meshtastic.proto.ToRadio
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * Demo Mode's contract with the app.
+ *
+ * The regression these tests exist for: the mock used to replay one combined frame array for *any* `want_config_id`, so
+ * stage 2 re-sent `my_info`, which resets the app's handshake state machine and makes the stage-2 `config_complete_id`
+ * get rejected. The handshake then never finished and Demo Mode showed an empty node list, no messages and an empty map
+ * β€” in every build, debug included.
+ */
+class MockRadioTransportTest {
+
+ private class RecordingCallback : RadioTransportCallback {
+ var connects = 0
+ val received = mutableListOf<FromRadio>()
+
+ override fun onConnect() {
+ connects++
+ }
+
+ override fun onDisconnect(isPermanent: Boolean, errorMessage: String?, reason: TransportDisconnectReason?) =
+ Unit
+
+ override fun handleFromRadio(bytes: ByteArray) {
+ received.add(FromRadio.ADAPTER.decode(bytes))
+ }
+
+ val nodeInfos
+ get() = received.mapNotNull { it.node_info }
+
+ val completions
+ get() = received.mapNotNull { it.config_complete_id }
+
+ val packets
+ get() = received.mapNotNull { it.packet }
+
+ fun packetsOn(portNum: PortNum) = packets.filter { it.decoded?.portnum == portNum }
+ }
+
+ /**
+ * The transport must not share the test's own scope: its live-telemetry loop never completes, so `runTest` would
+ * wait on it forever. A cancellable child scope on the test scheduler keeps virtual time shared but lets the test
+ * end.
+ */
+ private fun TestScope.transportScope() = CoroutineScope(StandardTestDispatcher(testScheduler))
+
+ @Test
+ fun `start signals onConnect without emitting frames`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ MockRadioTransport(callback, scope, address = "").start()
+
+ assertEquals(1, callback.connects)
+ assertTrue(callback.received.isEmpty())
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `config stage answers with my_info and its own nonce but never with node info`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+
+ assertEquals(1, callback.received.count { it.my_info != null }, "stage 1 must announce our node")
+ assertNotNull(callback.received.firstNotNullOfOrNull { it.metadata }, "stage 1 must send metadata")
+ assertTrue(callback.received.any { it.config?.lora != null }, "stage 1 must send LoRa config")
+ assertTrue(callback.received.any { it.channel != null }, "stage 1 must announce the primary channel")
+ assertTrue(callback.nodeInfos.isEmpty(), "node info belongs to stage 2 only")
+ assertEquals(listOf(HandshakeConstants.CONFIG_NONCE), callback.completions)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `node info stage does not re-send my_info because that would reset the handshake`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+ callback.received.clear()
+
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+
+ assertTrue(callback.received.none { it.my_info != null }, "re-sending my_info breaks stage 2")
+ assertEquals(listOf(HandshakeConstants.NODE_INFO_NONCE), callback.completions)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `node info stage populates a mesh whose nodes all render`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+ callback.received.clear()
+
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+
+ val nodes = callback.nodeInfos
+ assertTrue(nodes.size >= MIN_DEMO_NODES, "expected a populated node list, got ${nodes.size}")
+ assertEquals(nodes.size, nodes.distinctBy { it.num }.size, "node numbers must be unique")
+ nodes.forEach { node ->
+ val user = assertNotNull(node.user, "node ${node.num} has no user")
+ // An UNSET hardware model nulls the denormalised name columns, which flags the node as incomplete and
+ // hides it from search.
+ assertTrue(user.hw_model != HardwareModel.UNSET, "node ${node.num} has no hardware model")
+ assertTrue(user.long_name.isNotBlank(), "node ${node.num} has no long name")
+ assertTrue(user.short_name.isNotBlank(), "node ${node.num} has no short name")
+ // Without last_heard every node reads as offline and vanishes under the "online only" filter.
+ assertTrue(node.last_heard > 0, "node ${node.num} has no last_heard")
+ val position = assertNotNull(node.position, "node ${node.num} has no position")
+ // Presence, not "not zero": a scaled-integer 0 is a real coordinate on the equator and the prime
+ // meridian, so the sentinel form would reject a legitimately placed node.
+ assertNotNull(position.latitude_i, "node ${node.num} has no latitude")
+ assertNotNull(position.longitude_i, "node ${node.num} has no longitude")
+ assertNotNull(node.device_metrics?.battery_level, "node ${node.num} has no battery level")
+ }
+
+ // Checked across the mesh rather than per node, which is where "the coordinates are real" actually lives:
+ // the demo is meant to be spread out enough that the map has something to fit, and a per-node non-zero
+ // test never showed that anyway.
+ assertTrue(
+ nodes.distinctBy { it.position?.latitude_i to it.position?.longitude_i }.size == nodes.size,
+ "every demo node needs its own position or they stack on one map pin",
+ )
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `an unrecognised want_config_id is ignored rather than answered with the wrong stage`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+
+ transport.handleSendToRadio(ToRadio(want_config_id = 1234).encode())
+
+ assertTrue(callback.received.isEmpty())
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `seeded traffic supplies a channel thread a direct thread positions and telemetry`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+ val myNodeNum = assertNotNull(callback.received.firstNotNullOfOrNull { it.my_info }).my_node_num
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+ callback.received.clear()
+
+ testScheduler.advanceTimeBy(SEED_WINDOW_MS)
+
+ val texts = callback.packetsOn(PortNum.TEXT_MESSAGE_APP)
+ val broadcasts = texts.filter { it.to == BROADCAST_ADDR }
+ val directs = texts.filter { it.to == myNodeNum }
+ assertTrue(broadcasts.size >= 2, "expected a channel conversation, got ${broadcasts.size} messages")
+ assertTrue(directs.isNotEmpty(), "expected a direct-message thread")
+ // A broadcast has to land on the announced primary channel or it opens an unnamed "Channel N" thread.
+ assertTrue(broadcasts.all { it.channel == 0 }, "channel messages must use the primary channel")
+ // Across every frame, not just the texts: all of these ids come from one shared counter, and the app keys
+ // its lists by packet id β€” a repeat is dropped at best and a duplicate-key crash at worst.
+ val all = callback.packets
+ assertEquals(all.size, all.distinctBy { it.id }.size, "packet ids must be unique across the whole session")
+
+ assertTrue(callback.packetsOn(PortNum.POSITION_APP).isNotEmpty(), "expected position packets")
+
+ val telemetry = callback.packetsOn(PortNum.TELEMETRY_APP)
+ assertTrue(telemetry.size >= 2, "expected device telemetry from several nodes")
+ val decoded = telemetry.mapNotNull { it.decoded?.payload?.let(Telemetry.ADAPTER::decode) }
+ assertTrue(decoded.any { it.device_metrics != null }, "expected device metrics")
+ val environment = decoded.mapNotNull { it.environment_metrics }
+ assertTrue(
+ environment.any { it.temperature != null && it.relative_humidity != null },
+ "the Environment tab needs temperature AND humidity on the same telemetry",
+ )
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `received packets look like direct LoRa receptions so signal metrics reach the node list`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+ callback.received.clear()
+
+ testScheduler.advanceTimeBy(SEED_WINDOW_MS)
+
+ val positions = callback.packetsOn(PortNum.POSITION_APP)
+ assertTrue(positions.isNotEmpty())
+ // The app only harvests SNR/RSSI from LoRa packets, and only infers a hop count when hop_start is set.
+ assertTrue(
+ positions.all { it.transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA },
+ "packets left at the default transport are treated as internal and carry no signal info",
+ )
+ assertTrue(positions.all { it.hop_start > 0 }, "hop_start must be set for hop distance to be derivable")
+ // Two claims rather than `rx_time ?: 0 > 0`. That form was not unsound β€” an absent rx_time failed it too β€”
+ // but it reported a missing timestamp and an epoch-zero one identically.
+ assertTrue(positions.all { it.rx_time != null }, "every reception needs an rx_time; it drives lastHeard")
+ assertTrue(positions.all { (it.rx_time ?: 0) > 0 }, "rx_time must be a real timestamp, not epoch zero")
+
+ // Presence and variation are separate claims, and RSSI has to be checked for presence rather than for
+ // "not zero": 0 dBm is a legal (very strong) reading, so `rx_rssi ?: 0` would silently accept a packet
+ // that carries no RSSI at all β€” the exact sentinel-zero confusion the signal views suffer from.
+ assertTrue(positions.all { it.rx_rssi != null }, "every simulated reception must carry an RSSI reading")
+ assertTrue(
+ positions.mapTo(mutableSetOf()) { it.rx_rssi }.size > 1,
+ "expected RSSI to vary between nodes so the signal indicators differ",
+ )
+ // rx_snr has no presence bit in the proto (it defaults to 0f), so variation is all that can be asserted.
+ assertTrue(
+ positions.mapTo(mutableSetOf()) { it.rx_snr }.size > 1,
+ "expected SNR to vary between nodes so the signal indicators differ",
+ )
+ // At least one node must look like a direct neighbour, or nothing ever gets a signal reading at all.
+ assertTrue(positions.any { it.hop_start == it.hop_limit }, "expected at least one direct neighbour")
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ /**
+ * Disconnecting has to actually stop the simulator. Its live-telemetry loop, delayed replies and delayed acks all
+ * run on a scope that outlives the transport, so anything `close()` fails to cancel keeps pushing frames into a
+ * session the app has already torn down.
+ */
+ @Test
+ fun `close stops the simulated mesh`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "")
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+ // Drop the handshake frames before timing the seed pass. Asserting on a recorder that still holds them
+ // would pass even if the simulator went silent the moment the handshake ended, which would leave close()
+ // with nothing to stop and this test proving nothing.
+ callback.received.clear()
+
+ testScheduler.advanceTimeBy(SEED_WINDOW_MS)
+ assertTrue(
+ callback.received.isNotEmpty(),
+ "sanity: the seed pass must be emitting in its own right before close() is exercised",
+ )
+
+ // A text with want_ack leaves both a delayed ack and a delayed reply pending, so close() has more than the
+ // telemetry ticker to cancel.
+ transport.handleSendToRadio(
+ ToRadio(
+ packet =
+ MeshPacket(
+ id = 1,
+ to = BROADCAST_ADDR,
+ want_ack = true,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "ping".encodeUtf8()),
+ ),
+ )
+ .encode(),
+ )
+
+ transport.close()
+ callback.received.clear()
+
+ // Several live ticks' worth of virtual time β€” a surviving ticker would emit on every one of them, and a
+ // surviving reply or ack job would fire well inside the first.
+ testScheduler.advanceTimeBy(LIVE_TICK_MS * LIVE_TICKS_AFTER_CLOSE)
+
+ assertTrue(
+ callback.received.isEmpty(),
+ "close() must stop the simulator; got ${callback.received.size} frames afterwards",
+ )
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ private companion object {
+ const val BROADCAST_ADDR = -1
+ const val MIN_DEMO_NODES = 8
+
+ /** Comfortably longer than the seed pass, but shorter than the first live-telemetry tick. */
+ const val SEED_WINDOW_MS = 10_000L
+
+ /** Mirrors `MockRadioTransport.LIVE_TICK_MS`, which is private to the transport. */
+ const val LIVE_TICK_MS = 20_000L
+ const val LIVE_TICKS_AFTER_CLOSE = 5
+ }
+}

diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockTransportAddressAdmissionTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockTransportAddressAdmissionTest.kt
new file mode 100644
index 0000000000..d1fd62f889
--- /dev/null
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockTransportAddressAdmissionTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.radio
+
+import dev.mokkery.MockMode
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.ble.BleConnectionFactory
+import org.meshtastic.core.ble.BleScanner
+import org.meshtastic.core.ble.BluetoothRepository
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.RadioTransport
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * The Demo Mode gate has to govern *both* halves of the feature.
+ *
+ * Showing the entry in the Connections list is one path; admitting its `m`/`r` address is another. When those two read
+ * different sources the entry appears and then silently refuses to connect β€” which is why they now share one flow.
+ */
+class MockTransportAddressAdmissionTest {
+
+ private val gate = MutableStateFlow(false)
+
+ private val factory =
+ object :
+ BaseRadioTransportFactory(
+ scanner = mock<BleScanner>(MockMode.autofill),
+ bluetoothRepository = mock<BluetoothRepository>(MockMode.autofill),
+ connectionFactory = mock<BleConnectionFactory>(MockMode.autofill),
+ dispatchers =
+ CoroutineDispatchers(
+ io = Dispatchers.Unconfined,
+ main = Dispatchers.Unconfined,
+ default = Dispatchers.Unconfined,
+ ),
+ ) {
+ override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE)
+
+ override val mockTransportEnabled: StateFlow<Boolean> = gate
+
+ // Deliberately false: admission of the `r` address is governed by the gate alone. Whether the capture
+ // asset ships only decides if the entry is worth offering, and must not make `r` connectable when Demo
+ // Mode is locked.
+ override val isReplayTransportAvailable: Boolean = false
+
+ override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport =
+ NopRadioTransport(address)
+ }
+
+ @Test
+ fun `virtual transport addresses are refused while the gate is closed`() {
+ gate.value = false
+
+ assertFalse(factory.isAddressValid("m"), "mock address must not be admitted when Demo Mode is locked")
+ assertFalse(factory.isAddressValid("r"), "replay address must not be admitted when Demo Mode is locked")
+ }
+
+ @Test
+ fun `virtual transport addresses are admitted once the gate opens`() {
+ gate.value = true
+
+ assertTrue(factory.isAddressValid("m"))
+ assertTrue(factory.isAddressValid("r"))
+ }
+
+ @Test
+ fun `the gate is re-read on every check rather than captured once`() {
+ assertFalse(factory.isAddressValid("m"))
+
+ gate.value = true
+
+ assertTrue(factory.isAddressValid("m"), "admission must track a mid-session unlock")
+ }
+
+ @Test
+ fun `real transports are unaffected by the gate`() {
+ gate.value = false
+
+ assertTrue(factory.isAddressValid("t10.0.0.2"), "TCP must stay valid")
+ assertTrue(factory.isAddressValid("x11:22:33:44:55:66"), "BLE must stay valid")
+ assertFalse(factory.isAddressValid(null))
+ assertFalse(factory.isAddressValid(""))
+ }
+}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
index 97d526240e..814c97b1bd 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
@@ -122,8 +122,12 @@ interface RadioInterfaceService :
*/
val sessionGeneration: StateFlow<Long>
- /** Whether we are currently using a mock transport. */
- fun isMockTransport(): Boolean
+ /**
+ * Whether the virtual demo transports may be offered and bound right now.
+ *
+ * @see RadioTransportFactory.mockTransportEnabled
+ */
+ val mockTransportEnabled: StateFlow<Boolean>
/**
* Whether this build carries the packet capture the replay transport needs. False means selecting a replay address

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt
index 49ec0ed869..4f20acb0be 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.repository
+import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
@@ -28,8 +29,15 @@ interface RadioTransportFactory {
/** The device types supported by this factory. */
val supportedDeviceTypes: List<DeviceType>
- /** Whether we are currently forced into using a mock transport (e.g., Firebase Test Lab). */
- fun isMockTransport(): Boolean
+ /**
+ * Whether the virtual demo transports (`m` mock / `r` replay) may be offered and bound right now.
+ *
+ * Reactive rather than a one-shot check because it can flip at runtime: on Android the Demo Mode gesture (five taps
+ * on the Settings app-version row) unlocks it mid-session, and the Connections device list has to notice. Every
+ * consumer must read this one flow β€” the device-list visibility path and the [isAddressValid] admission path have
+ * to agree, or the demo entry appears and then refuses to connect.
+ */
+ val mockTransportEnabled: StateFlow<Boolean>
/**
* Whether this build can actually replay a packet capture rather than silently degrading to the plain mock. The

diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index 53511736f5..4d054deff2 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -724,7 +724,8 @@ class SharedRadioInterfaceService(
override fun consumeGattCacheInvalidationRequest(): Boolean = gattCacheInvalidationRequested.getAndSet(false)
- override fun isMockTransport(): Boolean = transportFactory.isMockTransport()
+ override val mockTransportEnabled: StateFlow<Boolean>
+ get() = transportFactory.mockTransportEnabled
override val isReplayTransportAvailable: Boolean
get() = transportFactory.isReplayTransportAvailable

diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
index 5f5852e3f7..481dc081c4 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
@@ -260,7 +260,7 @@ class SharedRadioInterfaceServiceLivenessTest {
every { networkRepository.resolvedList } returns MutableSharedFlow()
every { analytics.isPlatformServicesAvailable } returns false
every { transportFactory.supportedDeviceTypes } returns listOf(DeviceType.BLE)
- every { transportFactory.isMockTransport() } returns false
+ every { transportFactory.mockTransportEnabled } returns MutableStateFlow(false)
every { transportFactory.isAddressValid(any()) } returns true
every { transportFactory.toInterfaceAddress(any(), any()) } returns address
every { transportFactory.createTransport(any(), any()) } calls { transportProvider() }

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
index de05ab3452..a735740bae 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
@@ -150,7 +150,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
var restartTransportCalled: Boolean = false
private set
- override fun isMockTransport(): Boolean = true
+ override val mockTransportEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true)
/** No capture asset in tests; flip per-test when exercising replay-gated behaviour. */
override var isReplayTransportAvailable: Boolean = false

diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
index 2d6f634545..b36e93f3c9 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
@@ -16,6 +16,8 @@
*/
package org.meshtastic.desktop.radio
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
@@ -45,7 +47,8 @@ class DesktopRadioTransportFactory(
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
- override fun isMockTransport(): Boolean = false
+ // Desktop has no unlock gesture and no demo entry in its picker; the virtual transports stay inadmissible.
+ override val mockTransportEnabled: StateFlow<Boolean> = MutableStateFlow(false)
/** Desktop bundles no capture asset, and [createPlatformTransport] does not wire a replay address. */
override val isReplayTransportAvailable: Boolean = false

diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
index c9a9c43a2e..c2b001cfb4 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
@@ -84,7 +84,7 @@ class NoopRadioInterfaceService : RadioInterfaceService {
block: suspend (RadioSessionLease) -> Unit,
): Boolean = false
- override fun isMockTransport(): Boolean = false
+ override val mockTransportEnabled: StateFlow<Boolean> = MutableStateFlow(false)
override val isReplayTransportAvailable: Boolean = false

diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
index 048b00fba6..097a030232 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
@@ -150,10 +150,12 @@ open class ScannerViewModel(
) : ViewModel() {
// ── Mock / demo transport ─────────────────────────────────────────────────────────────────
- private val _showMockTransport = MutableStateFlow(false)
- /** Whether the mock/demo transport is currently selected. */
- val showMockTransport: StateFlow<Boolean> = _showMockTransport.asStateFlow()
+ /**
+ * Whether the Demo Mode entries belong in the device list. Observed rather than sampled once: in a release build
+ * the gate opens mid-session, when the user performs the hidden-features gesture in Settings.
+ */
+ val showMockTransport: StateFlow<Boolean> = radioInterfaceService.mockTransportEnabled
private val _showReplayTransport = MutableStateFlow(false)
@@ -225,7 +227,8 @@ open class ScannerViewModel(
.stateInWhileSubscribed(initialValue = DiscoveredDevices())
init {
- _showMockTransport.value = radioInterfaceService.isMockTransport()
+ // Sampled once, unlike [showMockTransport]: whether the replay capture ships is fixed when the build is
+ // assembled, so there is nothing for it to react to.
_showReplayTransport.value = radioInterfaceService.isReplayTransportAvailable
serviceRepository.connectionProgress.onEach { _connectionProgressText.value = it }.launchIn(viewModelScope)
serviceRepository.connectionState

diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
index 75a164fce1..55351bdb36 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelHarness.kt
@@ -89,12 +89,18 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
*/
val currentDeviceAddressFlow = MutableStateFlow<String?>(null)
+ /** Demo Mode gate, backing `radioInterfaceService.mockTransportEnabled`. Flip it to assert the reactive path. */
+ val mockTransportEnabled = MutableStateFlow(false)
+
val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
/**
* The `(showMock, showReplay)` pairs the ViewModel has asked for, in call order. Without this the fake would return
* the same devices for every visibility combination, so a test could pass while the ViewModel requested the wrong
* one β€” assert against this to prove the production path actually forwarded the intended flags.
+ *
+ * The order matters as much as the contents: the Demo Mode gate is observed rather than sampled, so a mid-session
+ * unlock has to show up here as a fresh request.
*/
val discoveryRequests = mutableListOf<Pair<Boolean, Boolean>>()
@@ -119,7 +125,7 @@ class ScannerViewModelHarness(val testDispatcher: TestDispatcher = UnconfinedTes
}
init {
- every { radioInterfaceService.isMockTransport() } returns false
+ every { radioInterfaceService.mockTransportEnabled } returns mockTransportEnabled
every { radioInterfaceService.isReplayTransportAvailable } returns false
every { radioInterfaceService.currentDeviceAddressFlow } returns currentDeviceAddressFlow
every { recentAddressesDataSource.recentAddresses } returns MutableStateFlow(emptyList())

diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
index 52058e21ae..7cb3efb7db 100644
--- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
+++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt
@@ -100,6 +100,66 @@ class ScannerViewModelTest {
assertNotNull(viewModel)
}
+ /**
+ * Demo Mode's gate opens mid-session in a release build, when the user performs the hidden-features gesture in
+ * Settings. Sampling it once in `init` (as this used to) meant the Connections list never noticed.
+ */
+ @Test
+ fun `showMockTransport follows the transport gate after construction`() = runTest {
+ viewModel.showMockTransport.test {
+ assertEquals(false, awaitItem())
+
+ harness.mockTransportEnabled.value = true
+ assertEquals(true, awaitItem())
+
+ harness.mockTransportEnabled.value = false
+ assertEquals(false, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ /**
+ * The gate has to reach the device list, not merely be observable on the ViewModel.
+ *
+ * Asserting on `showMockTransport` alone would pass even if the ViewModel stopped feeding the gate into the
+ * device-list query, so this asserts on the requests the use case actually received: one per gate value, in order.
+ * A gate sampled once at construction β€” which is what this branch fixes β€” records only its initial value here.
+ */
+ @Test
+ fun `a mid-session unlock re-queries the device list`() = runTest {
+ viewModel.usbDevicesForUi.test {
+ awaitItem()
+ testScheduler.runCurrent()
+ assertEquals(
+ listOf(false to false),
+ harness.discoveryRequests,
+ "a locked gate must still have queried the device list once",
+ )
+
+ // Each transition is checkpointed before the next one is provoked. Writing `true` and `false` back to
+ // back would let the StateFlow conflate them, and the `true` request β€” the one this whole feature exists
+ // to produce β€” could then never be observed, leaving the test green but vacuous. Waiting here fixes the
+ // ordering through the test's own control flow rather than through dispatcher timing, which is not a
+ // contract worth asserting on.
+ harness.mockTransportEnabled.value = true
+ testScheduler.runCurrent()
+ assertEquals(
+ listOf(false to false, true to false),
+ harness.discoveryRequests,
+ "unlocking Demo Mode mid-session must re-query the device list",
+ )
+
+ harness.mockTransportEnabled.value = false
+ testScheduler.runCurrent()
+ assertEquals(
+ listOf(false to false, true to false, false to false),
+ harness.discoveryRequests,
+ "re-locking must re-query it again",
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun `connectionProgressText reflects connectionProgress`() = runTest {
viewModel.connectionProgressText.test {
@@ -371,13 +431,14 @@ class ScannerViewModelTest {
/**
* Builds a ViewModel against the given transport capabilities and returns the distinct `(showMock, showReplay)`
- * pairs it asked the use case for. A fresh ViewModel is required because both flags are latched in `init`.
+ * pairs it asked the use case for. A fresh ViewModel is required because the replay flag is latched in `init` β€” the
+ * Demo Mode gate itself is observed, so it is set on the backing flow rather than stubbed.
*/
private suspend fun requestedVisibility(
mockTransport: Boolean,
replayAvailable: Boolean,
): List<Pair<Boolean, Boolean>> {
- every { harness.radioInterfaceService.isMockTransport() } returns mockTransport
+ harness.mockTransportEnabled.value = mockTransport
every { harness.radioInterfaceService.isReplayTransportAvailable } returns replayAvailable
harness.discoveryRequests.clear()
val subject = harness.buildBase()

Served by rngit 1.5.0 - Generated in 0.27s